Wilson@思源

目 录

实现图片旋转 翻转后保存

前言

看到论坛有不少小伙伴想要图片旋转/翻转后能保存覆盖原图片的功能。
虽然可以通过打开菜单打开外部程序编辑,但终究没在思源内操作方便。
最近研究了下,实现了这个功能。
支持 jpg, png, webp, bmp, gif(非动画)。
注意:该操作会覆盖原图,请严格测试后谨慎使用,操作前做好备份

效果

使用方法

1.
下载 browser-image-compression.js 放到 data/public 目录或使用在线地址,也可根据参数 compressionJsPath 进行配置
2.
把下面的代码片段放到思源 js 代码片段中
3.
在思源中操作完成(比如,旋转或翻转)后,点击右下角的保存按钮即可(如上图演示)

代码

js
// 功能:思源图片旋转/翻转后保存 // 版本: 0.0.2 // 更新记录 // 0.0.2 解决缓存问题造成操作后图片无法实时更新问题,同时改进了手机版免刷新更新图片 // 注意事项:该操作会覆盖原图,请严格测试后谨慎使用,操作前做好备份,由此造成的任何后果均与作者无关。 // 支持jpg,png,webp,bmp,gif(非动画) (async () => { // 使用方法: // 1. 下载js放到 data/public目录或在线路径,可根据下面的参数compressionJsPath进行配置 // 2. 把该代码片段放到思源js代码片段中即可 // 3. 在思源中操作完成(比如,旋转或翻转)后,点击右下角的保存按钮即可 // 图片压缩js路径或URL地址配置 // 图片压缩js下载地址(去掉https://ghp.ci/国内代理地址,即为github下载地址) // https://ghp.ci/https://raw.githubusercontent.com/Donaldcwl/browser-image-compression/refs/heads/master/dist/browser-image-compression.js const compressionJsPath = '/public/browser-image-compression.js'; // 图片保存后是否自动关闭弹窗, true关闭,false不关闭 const isCloseViewerAfterSave = true; // 图片压缩选项 // 更多参数可参考 https://github.com/Donaldcwl/browser-image-compression?tab=readme-ov-file#main-function const compressionOptions = { // 文件最大大小,会把图片压缩到该值以内 maxSizeMB: 2, // 图片最大宽度或高度,会等比缩放,限制在这个数值以内 maxWidthOrHeight: 1920, // 压缩图片的质量,该值应在 0 到 1 之间,【注意】这里如果设置小于1,多次操作后质量可能会逐渐下降 initialQuality: 1, // 是否使用work线程,加快压缩速度 useWebWorker: true, // worker线程js地址 libURL: compressionJsPath, //是否为 JPEG 图像保留 Exif 元数据,例如相机型号、焦距等 preserveExif: false, // 保持宽高不变 alwaysKeepResolution: true, } // 控制逻辑 if(siyuan.config.readonly) return; observeViewerContainer((viewer) => { const closeBtn = viewer.querySelector(".viewer-toolbar li.viewer-close"); if(!closeBtn) return; // 创建保存按钮 const li = document.createElement('li'); li.setAttribute('tabindex', '0'); li.setAttribute('role', 'button'); li.classList.add('viewer-save'); li.innerHTML = ''; li.onclick = () => { const image = viewer.querySelector(".viewer-canvas img"); saveImage(image, (filePath) => { reloadImages(filePath, 100); if(isCloseViewerAfterSave) closeBtn.click(); }); }; closeBtn.parentElement.insertBefore(li, closeBtn); }); // 功能函数区 function saveImage(image, callback) { const filePath = parseUrl(image.src); if(['http:', 'https:'].includes(filePath.protocol) && filePath.host !== location.host) { showMessage('仅支持本地文件'); return; } if(filePath.protocol === 'file:' && !isElectron()) { showMessage('仅在Electron环境支持file协议的图片'); return; } const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const width = image.naturalWidth; const height = image.naturalHeight; const transform = image.style.transform || image.style.webkitTransform; let rotation = 0; let scaleX = 1; let scaleY = 1; if (transform && transform !== 'none') { const values = transform.match(/(rotate\(|scaleX\(|scaleY\()([-+]?\d*\.?\d+)(deg)?\)/g); if (values) { values.forEach(value => { const [fullMatch, func, num, unit] = value.match(/(rotate\(|scaleX\(|scaleY\()([-+]?\d*\.?\d+)(deg)?\)/); if (func === 'rotate(') { rotation = parseFloat(num); } else if (func === 'scaleX(') { scaleX = parseFloat(num); } else if (func === 'scaleY(') { scaleY = parseFloat(num); } }); } } // 简化旋转角度到0-360度范围内 rotation = rotation % 360; // 处理负角度 if (rotation < 0) { rotation += 360; } // 根据旋转角度调整画布尺寸 let canvasWidth = width; let canvasHeight = height; if ([90, 270].includes(rotation)) { canvasWidth = height; canvasHeight = width; } // 设置画布尺寸 canvas.width = canvasWidth; canvas.height = canvasHeight; // 应用旋转和翻转 ctx.save(); ctx.translate(canvasWidth / 2, canvasHeight / 2); ctx.rotate(rotation * Math.PI / 180); ctx.scale(scaleX, scaleY); ctx.translate(-width / 2, -height / 2); // 绘制图片到canvas ctx.drawImage(image, 0, 0, width, height); // 恢复到保存前的状态,确保连续操作不会受到影响 ctx.restore(); if(filePath.protocol !== 'file:') filePath.path = filePath.path.replace('/', ''); // 将Data存到文件 canvas.toBlob(async (blob) => { let path = '/data/' + filePath.path; if(filePath.protocol === 'file:') path = 'file://' + filePath.path; if(!compressionJsPath){ await putFile(path, blob); } else { try { if(!document.querySelector('#browser-image-compression')){ await loadScript(compressionJsPath, 'browser-image-compression'); } const compressedFile = await imageCompression(blob, compressionOptions); await putFile(path, compressedFile); } catch (error) { console.error('Error compressing image:', error); } } if(callback) callback(filePath); }, 'image/'+filePath.extension.replace('jpg', 'jpeg')); } // 动态加载 browser-image-compression.js async function loadScript(url, id) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; if(id) script.id = id; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } function parseUrl(url) { // 创建一个URL对象 const parsedUrl = new URL(url); // 获取路径部分 const pathname = parsedUrl.pathname; // 获取文件名和扩展名 const filenameWithExtension = pathname.split('/').pop(); const [filename, extension] = filenameWithExtension.split('.'); return { path: decodeURIComponent(pathname), filename: decodeURIComponent(filenameWithExtension), extension: extension, search: parsedUrl.search, protocol: parsedUrl.protocol, host: parsedUrl.host, }; } function reloadImages(filePath, delay) { const imgs = document.querySelectorAll('.protyle-wysiwyg [data-type="img"] img[src*="'+filePath.path+'"]'); if(imgs.length > 0) { setTimeout(()=>{ imgs.forEach(async img => { let src = ''; if(filePath.protocol === 'file:'){ src = img.src; } else { src = filePath.path + filePath.search; } src = src + (src.indexOf('?')===-1?'?':'&') + 't=' + new Date().getTime(); img.src = src; img.dataset.src = src; updateBlock(img.closest('[data-type][data-node-id]')); }); }, delay||0); } } function updateBlock(node) { fetchSyncPost('/api/block/updateBlock', { "dataType": "dom", "data": node.outerHTML, "id": node.dataset.nodeId }) } function putFile(storagePath, data) { if(storagePath.startsWith('file://')) { return putLocalFileSync(storagePath.replace('file://', ''), data); } const formData = new FormData(); formData.append("path", storagePath); formData.append("file", new Blob([data])); return fetch("/api/file/putFile", { method: "POST", body: formData, }).then((response) => { if (response.ok) { //console.log("File saved successfully"); } else { throw new Error("Failed to save file"); } }).catch((error) => { console.error(error); }); } let fs; async function putLocalFileSync(filePath, data) { try { // 将 Blob 转换为 ArrayBuffer const arrayBuffer = await data.arrayBuffer(); // 将 ArrayBuffer 转换为 Buffer const buffer = Buffer.from(arrayBuffer); // 使用 fs.writeFileSync 方法同步地写入文件 if(!fs) fs = require('fs'); fs.writeFileSync(filePath, buffer); } catch (error) { console.error(`Error writing file to ${filePath}:`, error); } } function isElectron() { return navigator.userAgent.includes('Electron'); } function isMobile() { return !!document.getElementById("sidebar"); } function showMessage(message, delay) { fetchSyncPost("/api/notification/pushMsg", { "msg": message, "timeout": delay || 7000 }); } async function fetchSyncPost(url, data, returnType = 'json') { const init = { method: "POST", }; if (data) { if (data instanceof FormData) { init.body = data; } else { init.body = JSON.stringify(data); } } try { const res = await fetch(url, init); const res2 = returnType === 'json' ? await res.json() : await res.text(); return res2; } catch(e) { console.log(e); return returnType === 'json' ? {code:e.code||1, msg: e.message||"", data: null} : ""; } } function observeViewerContainer(callback) { // 创建一个新的MutationObserver实例 const observer = new MutationObserver((mutationsList, observer) => { for (let mutation of mutationsList) { if (mutation.type === 'childList') { // 检查是否有新的 .viewer-container 元素被添加 for (let node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('viewer-container')) { callback(node); // 调用回调函数 break; // 找到后退出循环 } } } } }); // 配置观察选项,这里观察直接子节点的变化 const config = { childList: true }; // 开始观察body元素 observer.observe(document.body, config); } })();
代码备份地址:https://gitee.com/wish163/mysoft/blob/master/%E6%80%9D%E6%BA%90%E5%9B%BE%E7%89%87%E6%97%8B%E8%BD%AC%E7%BF%BB%E8%BD%AC%E5%90%8E%E4%BF%9D%E5%AD%98.js

参数说明

1.
compressionJsPath​ 用于图片压缩的 js 库,建议下载到本地,该库使用第三方 browser-image-compression 库。
2.
isCloseViewerAfterSave,图片保存后是否自动关闭弹窗, true 关闭,false 不关闭
3.
compressionOptions,图片压缩选项,请参考源码中的解释,详情请参考:https://github.com/Donaldcwl/browser-image-compression?tab=readme-ov-file#main-function

后记

分享 10 分钟,开发数天功,看似简短的代码背后,是开发者日夜辛苦的汗水,如果脚本对你有帮助就多给 ta 点点赞吧
比较赞同,J 佬的这句话“用户只要在一堆软件插件里面挑挑拣拣,好用的夸两句,难用的骂两句就可以,可是开发者要考虑的事情就很多了。更多”。
比如,这里简单列举下,你觉得简单的问题,开发者要考虑的问题可能有(但远不止这些)
图片链接类型,相对路径,绝对路径,本地/远程路径,包括相关协议
兼容问题,手机版,windows 版,web 版,发布版等
翻转,旋转交叉问题,先后操作及多次操作结果是否一致?消除与简化
思源的翻转是 css 实现的,如何把 css 有效的解析为 canvas 内容
保存后图片刷新问题,多端不兼容如何处理
路径空格及编码问题,不同平台路径兼容问题
图片格式问题,多种图片格式如何同时兼容
图片压缩问题,canvas 编辑后可能导致体积膨胀
。。。。。。
开发中遇到的问题有:
1.
getComputedStyle 获取的 transform 结果是一个矩阵,刚开始解析矩阵,但总是和 css 的实际结果有出入,后来改用获取 style 属性方法去解析就没问题,不清楚是不是 js bug 还是什么问题。
2.
非 electron 环境,写文件到工作空间外较复杂,非 electron 环境暂不支持 file://​ 协议。

免责声明

该操作会覆盖原图,请严格测试后谨慎使用,操作前做好备份,由此造成的任何后果均与作者无关。